/*
 * Copyright (C) 2014 Google, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package io.polaris.core.javapoet;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import javax.lang.model.element.Element;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.SimpleElementVisitor8;

import static io.polaris.core.javapoet.Util.checkArgument;
import static io.polaris.core.javapoet.Util.checkNotNull;

/** A fully-qualified class name for top-level and member classes. */
public final class ClassName extends TypeName implements Comparable<ClassName> {
	public static final ClassName OBJECT = ClassName.get(Object.class);

	/** The name representing the default Java package. */
	private static final String NO_PACKAGE = "";

	/** The package name of this class, or "" if this is in the default package. */
	final String packageName;

	/** The enclosing class, or null if this is not enclosed in another class. */
	final ClassName enclosingClassName;

	/** This class name, like "Entry" for java.util.Map.Entry. */
	final String simpleName;

	private List<String> simpleNames;

	/** The full class name like "java.util.Map.Entry". */
	final String canonicalName;

	private ClassName(String packageName, ClassName enclosingClassName, String simpleName) {
		this(packageName, enclosingClassName, simpleName, Collections.emptyList());
	}

	private ClassName(String packageName, ClassName enclosingClassName, String simpleName,
		List<AnnotationSpec> annotations) {
		super(annotations);
		this.packageName = Objects.requireNonNull(packageName, "packageName == null");
		this.enclosingClassName = enclosingClassName;
		this.simpleName = simpleName;
		this.canonicalName = enclosingClassName != null
			? (enclosingClassName.canonicalName + '.' + simpleName)
			: (packageName.isEmpty() ? simpleName : packageName + '.' + simpleName);
	}

	@Override
	public ClassName annotated(List<AnnotationSpec> annotations) {
		return new ClassName(packageName, enclosingClassName, simpleName,
			concatAnnotations(annotations));
	}

	@Override
	public ClassName withoutAnnotations() {
		if (!isAnnotated()) return this;
		ClassName resultEnclosingClassName = enclosingClassName != null
			? enclosingClassName.withoutAnnotations()
			: null;
		return new ClassName(packageName, resultEnclosingClassName, simpleName);
	}

	@Override
	public boolean isAnnotated() {
		return super.isAnnotated() || (enclosingClassName != null && enclosingClassName.isAnnotated());
	}

	/**
	 * Returns the package name, like {@code "java.util"} for {@code Map.Entry}. Returns the empty
	 * string for the default package.
	 */
	public String packageName() {
		return packageName;
	}

	/**
	 * Returns the enclosing class, like {@link Map} for {@code Map.Entry}. Returns null if this class
	 * is not nested in another class.
	 */
	public ClassName enclosingClassName() {
		return enclosingClassName;
	}

	/**
	 * Returns the top class in this nesting group. Equivalent to chained calls to {@link
	 * #enclosingClassName()} until the result's enclosing class is null.
	 */
	public ClassName topLevelClassName() {
		return enclosingClassName != null ? enclosingClassName.topLevelClassName() : this;
	}

	/** Return the binary name of a class. */
	public String reflectionName() {
		return enclosingClassName != null
			? (enclosingClassName.reflectionName() + '$' + simpleName)
			: (packageName.isEmpty() ? simpleName : packageName + '.' + simpleName);
	}

	public List<String> simpleNames() {
		if (simpleNames != null) {
			return simpleNames;
		}

		if (enclosingClassName == null) {
			simpleNames = Collections.singletonList(simpleName);
		} else {
			List<String> mutableNames = new ArrayList<>();
			mutableNames.addAll(enclosingClassName().simpleNames());
			mutableNames.add(simpleName);
			simpleNames = Collections.unmodifiableList(mutableNames);
		}
		return simpleNames;
	}

	/**
	 * Returns a class that shares the same enclosing package or class. If this class is enclosed by
	 * another class, this is equivalent to {@code enclosingClassName().nestedClass(name)}. Otherwise
	 * it is equivalent to {@code get(packageName(), name)}.
	 */
	public ClassName peerClass(String name) {
		return new ClassName(packageName, enclosingClassName, name);
	}

	/**
	 * Returns a new {@link ClassName} instance for the specified {@code name} as nested inside this
	 * class.
	 */
	public ClassName nestedClass(String name) {
		return new ClassName(packageName, this, name);
	}

	/** Returns the simple name of this class, like {@code "Entry"} for {@link java.util.Map.Entry}. */
	public String simpleName() {
		return simpleName;
	}

	/**
	 * Returns the full class name of this class.
	 * Like {@code "java.util.Map.Entry"} for {@link java.util.Map.Entry}.
	 */
	public String canonicalName() {
		return canonicalName;
	}

	public static ClassName get(Class<?> clazz) {
		checkNotNull(clazz, "clazz == null");
		checkArgument(!clazz.isPrimitive(), "primitive types cannot be represented as a ClassName");
		checkArgument(!void.class.equals(clazz), "'void' type cannot be represented as a ClassName");
		checkArgument(!clazz.isArray(), "array types cannot be represented as a ClassName");

		String anonymousSuffix = "";
		while (clazz.isAnonymousClass()) {
			int lastDollar = clazz.getName().lastIndexOf('$');
			anonymousSuffix = clazz.getName().substring(lastDollar) + anonymousSuffix;
			clazz = clazz.getEnclosingClass();
		}
		String name = clazz.getSimpleName() + anonymousSuffix;

		if (clazz.getEnclosingClass() == null) {
			// Avoid unreliable Class.getPackage(). https://github.com/square/javapoet/issues/295
			int lastDot = clazz.getName().lastIndexOf('.');
			String packageName = (lastDot != -1) ? clazz.getName().substring(0, lastDot) : NO_PACKAGE;
			return new ClassName(packageName, null, name);
		}

		return ClassName.get(clazz.getEnclosingClass()).nestedClass(name);
	}

	/**
	 * Returns a new {@link ClassName} instance for the given fully-qualified class name string. This
	 * method assumes that the input is ASCII and follows typical Java style (lowercase package
	 * names, UpperCamelCase class names) and may produce incorrect results or throw
	 * {@link IllegalArgumentException} otherwise. For that reason, {@link #get(Class)} and
	 * {@link #get(Class)} should be preferred as they can correctly create {@link ClassName}
	 * instances without such restrictions.
	 */
	public static ClassName bestGuess(String classNameString) {
		// Add the package name, like "java.util.concurrent", or "" for no package.
		int p = 0;
		while (p < classNameString.length() && Character.isLowerCase(classNameString.codePointAt(p))) {
			p = classNameString.indexOf('.', p) + 1;
			checkArgument(p != 0, "couldn't make a guess for %s", classNameString);
		}
		String packageName = p == 0 ? NO_PACKAGE : classNameString.substring(0, p - 1);

		// Add class names like "Map" and "Entry".
		ClassName className = null;
		for (String simpleName : classNameString.substring(p).split("\\.", -1)) {
			checkArgument(!simpleName.isEmpty() && Character.isUpperCase(simpleName.codePointAt(0)),
				"couldn't make a guess for %s", classNameString);
			className = new ClassName(packageName, className, simpleName);
		}

		return className;
	}

	/**
	 * Returns a class name created from the given parts. For example, calling this with package name
	 * {@code "java.util"} and simple names {@code "Map"}, {@code "Entry"} yields {@link java.util.Map.Entry}.
	 */
	public static ClassName get(String packageName, String simpleName, String... simpleNames) {
		ClassName className = new ClassName(packageName, null, simpleName);
		for (String name : simpleNames) {
			className = className.nestedClass(name);
		}
		return className;
	}

	/** Returns the class name for {@code element}. */
	public static ClassName get(TypeElement element) {
		checkNotNull(element, "element == null");
		String simpleName = element.getSimpleName().toString();

		return element.getEnclosingElement().accept(new SimpleElementVisitor8<ClassName, Void>() {
			@Override
			public ClassName visitPackage(PackageElement packageElement, Void p) {
				return new ClassName(packageElement.getQualifiedName().toString(), null, simpleName);
			}

			@Override
			public ClassName visitType(TypeElement enclosingClass, Void p) {
				return ClassName.get(enclosingClass).nestedClass(simpleName);
			}

			@Override
			public ClassName visitUnknown(Element unknown, Void p) {
				return get("", simpleName);
			}

			@Override
			public ClassName defaultAction(Element enclosingElement, Void p) {
				throw new IllegalArgumentException("Unexpected type nesting: " + element);
			}
		}, null);
	}

	@Override
	public int compareTo(ClassName o) {
		return canonicalName.compareTo(o.canonicalName);
	}

	@Override
	CodeWriter emit(CodeWriter out) throws IOException {
		boolean charsEmitted = false;
		for (ClassName className : enclosingClasses()) {
			String simpleName;
			if (charsEmitted) {
				// We've already emitted an enclosing class. Emit as we go.
				out.emit(".");
				simpleName = className.simpleName;

			} else if (className.isAnnotated() || className == this) {
				// We encountered the first enclosing class that must be emitted.
				String qualifiedName = out.lookupName(className);
				int dot = qualifiedName.lastIndexOf('.');
				if (dot != -1) {
					out.emitAndIndent(qualifiedName.substring(0, dot + 1));
					simpleName = qualifiedName.substring(dot + 1);
					charsEmitted = true;
				} else {
					simpleName = qualifiedName;
				}

			} else {
				// Don't emit this enclosing type. Keep going so we can be more precise.
				continue;
			}

			if (className.isAnnotated()) {
				if (charsEmitted) out.emit(" ");
				className.emitAnnotations(out);
			}

			out.emit(simpleName);
			charsEmitted = true;
		}

		return out;
	}

	/** Returns all enclosing classes in this, outermost first. */
	private List<ClassName> enclosingClasses() {
		List<ClassName> result = new ArrayList<>();
		for (ClassName c = this; c != null; c = c.enclosingClassName) {
			result.add(c);
		}
		Collections.reverse(result);
		return result;
	}
}
